Fix: origin-rewriting wrappers bypass high-security restrictions#610
Fix: origin-rewriting wrappers bypass high-security restrictions#610n13 wants to merge 4 commits into
Conversation
for all origin-altering calls
Bound `HighSecurityConfig::call_allowed_for` to a fixed nesting depth (MAX_CALL_DEPTH = 16) and fail closed beyond it, so deeply nested origin-rewriting/combinator calls cannot exhaust transaction-validation work. Adds a regression test.
There was a problem hiding this comment.
Verdict: Approve — no remaining blockers from this review.
Update: the only blocker from my earlier review (the failing Format check) is resolved. Commit e196524a ("format") makes the Fast Checks (Format) job pass, and git show -w confirms it is whitespace-only (rustfmt line-reflow in runtime/src/transaction_extensions.rs — weight(), validate_with(), and two test constructors), so the logic and tests validated earlier are unaffected. (Build & Test / Clippy & Doc were still running at review time — merge once they go green.)
Effective-origin high-security enforcement (reconfirmed):
- The recursive resolver re-checks the whitelist at the effective dispatched origin, covering the origin-rewriting wrappers (
Recovery::as_recovered,Utility::as_derivative) and the origin-preserving combinators (batch/batch_all/force_batch/if_else). - The remaining
pallet-utilityorigin-altering calls —dispatch_as,with_weight,dispatch_as_fallible— are allensure_root, so they are not an unprivileged bypass and correctly are not traversed. The scheduler's extrinsics are disabled. ReversibleTransactionExtensionandpallet-multisigshare the singleHighSecurityInspector::is_call_allowedimplementation (no duplicated logic); multisig re-checks at execute time against the multisig address.- Recursion is bounded (
MAX_CALL_DEPTH = 16) and fails closed, well under theMAX_EXTRINSIC_DEPTH = 256decode limit;weight()scales with the number of traversed nodes. - Recovery address handling is safe: this runtime sets
type Lookup = AccountIdLookup, which only resolvesMultiAddress::Id, so the non-Idarm cannot dispatch and is not an alternate-encoding bypass.
No security or logic blocker found.
Local tests (still valid — the format commit is whitespace-only):
cargo test -p quantus-runtime --lib transaction_extensions::testspassedcargo test -p pallet-multisigpassedcargo test -p pallet-reversible-transferspassed
n13
left a comment
There was a problem hiding this comment.
Updated verdict after the format commit: previous blocker cleared.
The new format commit fixed the issue I flagged; GitHub's Fast Checks (Format) job is now passing. My code-review verdict is approve/merge-ready from the effective-origin high-security enforcement perspective.
Remaining note: the build/test matrix and clippy/doc jobs are still running on the latest commit, so I would wait for those checks to finish green before merging.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 32c521a. Configure here.
| Self::call_allowed_for(target, call, depth + 1), | ||
| // Other address kinds are unresolvable by the runtime lookup and cannot | ||
| // dispatch, so there is no effective origin to enforce. | ||
| _ => true, |
There was a problem hiding this comment.
Recovery non-Id skips HS check
High Severity
In call_allowed_for, Recovery::as_recovered only recurses into the inner call when account is MultiAddress::Id. Other lookup forms return true without inspecting the inner call. as_recovered resolves accounts via T::Lookup::lookup at dispatch, so a non-whitelisted inner call for a high-security account can pass validation and still execute.
Reviewed by Cursor Bugbot for commit 32c521a. Configure here.
| if T::HighSecurity::is_high_security(&multisig_address) && | ||
| !T::HighSecurity::is_whitelisted(&call) | ||
| { | ||
| if !T::HighSecurity::is_call_allowed(&multisig_address, &call) { |
There was a problem hiding this comment.
Propose skips recursive HS check
Medium Severity
propose still gates high-security multisigs with a shallow is_high_security plus is_whitelisted check on the decoded call, while execute now uses is_call_allowed, which applies the depth limit and recursive origin resolution. Proposals can be created and approved whose inner calls execute will always reject.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 32c521a. Configure here.
| _ => 0, | ||
| }; | ||
| inner.saturating_add(1) | ||
| } |
There was a problem hiding this comment.
Weight counts unbounded call depth
Medium Severity
high_security_read_count walks the full nested call tree with no MAX_CALL_DEPTH cap, while call_allowed_for stops rejecting after depth 16. ReversibleTransactionExtension::weight uses this count for every signed extrinsic, so pathological nesting up to the decode limit can force large recursive work during fee estimation even when validation fails early on depth.
Reviewed by Cursor Bugbot for commit 32c521a. Configure here.


Summary
High-security (HS) accounts are meant to be limited to whitelisted calls (e.g. reversible
schedule_transfer/cancel). They could be bypassed:Recovery::as_recoveredandUtility::as_derivativesynthesize a freshSignedorigin after top-level transaction validation, so a non-HS outer signer could dispatch a non-whitelisted call as an HS account — including when nested underbatch/batch_all/force_batch/if_else.This PR enforces the HS whitelist at the effective dispatched origin, entirely at the runtime/extension layer:
HighSecurityConfig::call_allowed_forrecursively resolves origin-rewriting wrappers (as_recovered/as_derivative) and origin-preserving combinators (batch*/if_else), re-checking the whitelist at each effective signer. Root-onlydispatch_as/with_weightand the scheduler are intentionally not traversed.HighSecurityInspector::is_call_allowedprovided method, soReversibleTransactionExtensionandpallet-multisiguse one implementation (no duplicated logic).MAX_CALL_DEPTH = 16, well belowMAX_EXTRINSIC_DEPTH = 256): over-nested calls are rejected rather than allowed to escape the whitelist, so validation work can't be exhausted.weight()scales with the number of nodes traversed (oneis_high_securityread per node).pallet-recoveryandpallet-utilityare left unchanged from upstream Substrate.Test plan
cargo test -p quantus-runtime --lib transaction_extensions::tests— origin-rewriter bypass blocked, whitelisted inner call allowed, nested-in-batch bypass blocked, non-HS origin unaffected, over-nested call rejected fail-closedcargo test -p pallet-multisigcargo test -p pallet-reversible-transfersNote
High Risk
This is a security fix for account restriction enforcement; incorrect recursion or depth handling could still allow HS bypass or reject valid transactions.
Overview
Closes a bypass where high-security (HS) call limits could be evaded by wrapping disallowed inner calls in
Recovery::as_recoveredorUtility::as_derivative(andbatch*/if_else), because those pallets rewrite the signed origin after top-level validation.HighSecurityInspector::is_call_allowedis added with a default that only checks the top-level call; the runtime overrides it viaHighSecurityConfig::call_allowed_for, which recursively walks origin-rewriting and batching wrappers, re-applies the HS whitelist at each effective signer, and rejects over-deep nesting (MAX_CALL_DEPTH = 16, fail-closed).ReversibleTransactionExtensionnow uses this helper instead of a shallow HS + whitelist check, and itsweight()scales withhigh_security_read_count.pallet-multisigexecutere-checks withis_call_allowedinstead of only testing the outer call against the whitelist.Runtime tests cover blocked/allowed paths for
as_recovered,as_derivative, batch-wrapped bypasses, and excessive nesting.Reviewed by Cursor Bugbot for commit 32c521a. Configure here.